feat: add AnswerSettings and QuestionSettingsHeader components - #6059
feat: add AnswerSettings and QuestionSettingsHeader components #6059Abhishek-Punhani wants to merge 3 commits into
Conversation
|
👋 Hi @Abhishek-Punhani, thanks for contributing! For the review process to begin, please verify that the following is satisfied:
Also check that issue requirements are satisfied & you ran Pull requests that don't follow the guidelines will be closed. Reviewer assignment can take up to 2 weeks. |
|
@AlexVelezLl, Vue 2.7 doesn't support the built-in Teleport component, so I've added a custom component to mirror its behaviour. Let me know if you have a better implementation in mind! |
|
Oh, apologies @Abhishek-Punhani 😅, I just knew we had already used it in our ecosystem and didn't recall it was a dependency package. In KDS, we use vue2-teleport. Could you install that same version on Studio, please? I read it has some memory optimizations that'd be good to have! |
AlexVelezLl
left a comment
There was a problem hiding this comment.
Thanks @Abhishek-Punhani! I think there is a better way to do this to not remove the type selector from the DOM when we change the question type. Please let us know if there is any questions!
There was a problem hiding this comment.
Oh, given that this will only be used in the editor, could we move this component to the choice folder instead?
| settings: { | ||
| type: Array, | ||
| required: true, | ||
| validator: arr => arr.every(setting => ['shuffle', 'showAnswerCount'].includes(setting)), | ||
| }, |
There was a problem hiding this comment.
Given that this will only be rendered for choice interactions, I think it's fine to let it infer when to display each based on the questionType instead of this settings prop.
| :title="showAnswerCountInfoTitle$()" | ||
| @cancel="showAnswerCountModal = false" | ||
| > | ||
| <p :style="{ color: $themeTokens.annotation }"> |
There was a problem hiding this comment.
I think we can leave the normal text color here, instead of this annotation.
| <template #actions> | ||
| <KButton | ||
| :text="closeBtnLabel$()" | ||
| @click="showAnswerCountModal = false" | ||
| /> | ||
| </template> |
There was a problem hiding this comment.
We can also just use the cancelText prop instead of this actions slot. Usually the actions slot is used for more complex button layouts.
| :title="shuffleAnswersInfoTitle$()" | ||
| @cancel="showShuffleModal = false" | ||
| > | ||
| <p :style="{ color: $themeTokens.annotation }"> |
There was a problem hiding this comment.
Actually, the most important responsibility of this component is the type selector, so could we reference "type selector" in the name instead of "SettingsHeader"? (Similarly for class names like question-settings-header, etc)
| <template> | ||
|
|
||
| <div | ||
| v-if="mode === 'edit'" |
There was a problem hiding this comment.
We sometimes have issues because of rendering conditions on the root element of a component, and a lint rule will soon be added to avoid this, so could we move forward with this condition and leave this responsibility to the parent component instead? i.e. let the parent do <QuestionTypeSelector v-if="mode==='edit'" instead
| @update:showAnswerCount="setShowAnswerCount" | ||
| /> | ||
| </template> | ||
| </QuestionSettingsHeader> |
There was a problem hiding this comment.
Oh, some comments: the interaction editors should not be the ones responsible for rendering this "settings header," which is actually a type selector, mainly because this will cause the DOM to remove these nodes when the component is unmounted. The type selector (and therefore, the whole header row) should be shared across all interaction editors and rendered independently of them, so that if we change the question type, the type selector component is not removed from the DOM (which would cause some accessibility issues).
So, what we can do instead is:
- Have the
InteractionSectioncomponent be the one that renders theQuestionTypeSelectorcomponent. This way, each interaction has its own type selector independent of the editor being rendered. - Let the
QuestionTypeSelectorbe the one responsible for rendering the div with the proper ID so that editor components can target it (instead of using a slot). - We can take advantage of the fact that we can only have one item being edited at a time, and because of this, there will always only be one question type selector rendered at a time, and use a constant as
idso that we don't have to keep track of any identifiers yet.
With this, the idea would be:
// QuestionTypeSelector
<div class="type-selector>
...
<div id="qti-interaction-settings" />
...// InteractionSection
<QuestionTypeSelector ... />
<component :is="descriptor.editorComponent" ... />// ChoiceInteractionEditor
<div class="choice-editor">
<Teleport to="qti-interaction-settings">
<AnswerSettings ... />
</Teleport>
....
</div>This way only the interaction editors that actually wants to add settings are the only ones that needs to teleport anything.
| const questionTypeOptions = computed(() => [ | ||
| { | ||
| value: QuestionType.SINGLE_SELECT, | ||
| label: singleSelectLabel$(), | ||
| description: singleChoiceDescription$(), | ||
| }, | ||
| { | ||
| value: QuestionType.MULTI_SELECT, | ||
| label: multiSelectLabel$(), | ||
| description: multipleSelectionDescription$(), | ||
| }, | ||
| ]); |
There was a problem hiding this comment.
Another little problem with this is that the questionType selector should render all question types for all interactions, not only the ones they are related to.
9a4d043 to
b385729
Compare
|
📢✨ Before we assign a reviewer, we'll turn on |
🔵 Review postedLast updated: 2026-08-05 11:43 UTC |
rtibblesbot
left a comment
There was a problem hiding this comment.
PR #6059 — the Answer settings half of #6033 is clean: Composition API throughout, theme tokens only, useKResponsiveWindow instead of media queries, every string through qtiEditorStrings. The type-selector half has three correctness problems.
CI passing. Manual QA was required for this PR but did not run (dev server failed to start), so nothing here asserts visual correctness — the rendered layout, the globe adornment, and small-screen behaviour are unverified.
blocking
- Type dropdown is populated from the whole interaction registry, so a choice question can be switched to a text-entry type and its XML is silently overwritten (
useInteractionDescriptor.js:70). KRadioButtonGroupwraps a list that renders checkboxes for multiple choice — verified TypeError in Firefox (ChoiceInteractionEditor.vue:80).max-choices/min-choicesnow derive from the correct-answer count for single choice too, against the issue's explicitmax-choicesis always1(useChoiceInteraction.js:39).
suggestion / nitpick — see inline: Teleport unregistered in Jest so the new Answer-settings tests never exercise the teleport, dead teleport-target defaults, duplicated buildXML pipeline, showAnswerCount stored inside parsed state, untested update:questionType and cardinality acceptance criteria, missing group names on the two new control clusters.
Two smaller points that don't map to changed lines: the deleted comment <!-- Per-choice validation messages sit INSIDE the bordered card --> (ChoiceInteractionEditor.vue, ~line 165) describes code that is still there — AGENTS.md asks for existing comments to be preserved. And each question now installs two document-body MutationObservers via vue2-teleport; on a long assessment with TipTap running, worth watching during QA.
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Ran a phased review pipeline over the pull request diff:
- Classified the diff to select review passes (core, frontend, backend) and whether manual QA was required
- Core review pass checked correctness, design, architecture, testing, completeness, and DRY/SRP/Rule-of-Three principles
- Specialized frontend/backend review passes applied framework-specific lenses where those files changed
- For UI changes: manual QA and an accessibility audit against a live dev server, when available
- Checked CI status and linked issue acceptance criteria
- Synthesized one review from those passes and chose the verdict from the findings, CI status, and QA evidence
| ); | ||
|
|
||
| return { descriptor, questionType, parseError }; | ||
| const typeOptions = computed(() => { |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
blocking: descriptors is the full registry (interactions/index.js:15), so a choice question's KSelect offers all five types, including the text-entry ones. Selecting one is a data-loss path: descriptor recomputes to the text-entry descriptor (line 64), <component :is> mounts TextEntryEditor with :interaction still holding the choice block, and its { immediate: true } watcher on workingInteraction (TextEntryEditor.vue:340) emits update:interaction on mount — which QTIItemEditor.onUpdateInteraction writes straight into currentBodyXml. Both descriptors declare convertsFrom = [], so there is no conversion step; the author's choice question is just replaced.
This is also outside #6033's scope ("The modal lists only the types that the current interaction plugin supports") and fails the criterion "Selector shows 'Single choice' and 'Multiple choice' for choice interactions".
Source the options from the resolved descriptor instead:
const typeOptions = computed(() => descriptor.value.getTypeOptions?.(qtiEditorStrings) ?? []);That also makes :disabled="questionTypeOptions.length <= 1" meaningful, and lets TextEntryInteractionDescriptor.getTypeOptions stay for when text-entry switching is actually built. Note selectedOption in QuestionTypeSelector would then need a guard — questionTypeOptions[0] is undefined for a descriptor without getTypeOptions, and the template dereferences selectedOption.label.
There was a problem hiding this comment.
Yeah, we should not have this coming from useInteractionDescriptor as it does not depend on the descriptor. The TypeSelector component should be able to declare its own list of type options instead of having it come from props.
There was a problem hiding this comment.
You're right, and I was wrong on the scope half of this — you've stated the selector should render all question types across all interactions, so the full registry is intentional and my "outside #6033's scope" objection doesn't hold. Withdrawing the descriptor-sourced typeOptions remedy too; the TypeSelector owning its own list is your call and I'll defer to it. Apologies for the noise.
| </div> | ||
|
|
||
| <div class="choices-list"> | ||
| <KRadioButtonGroup class="choices-list"> |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
blocking: the list only contains KRadioButtons when isSingleSelect is true — multi-select renders KCheckbox (lines ~120-140). KDS KRadioButtonGroup.vue mounted() (verified in node_modules, lines 41-56) does:
this.lastRadioIdx = this.radioButtons.length - 1;
const firstRadioButton = this.radioButtons[this.focusedRadioIdx]; // undefined
firstRadioButton.setTabIndex(0); // TypeErrorwith no empty guard. The whole block is behind if (!this.isFirefox) return;, so this won't show up in Jest or Chrome QA, but it breaks the editor for every multiple-choice question in Firefox. Same crash for a single-choice question where every choice has a validation error, since the error KIcon replaces the radio button.
Separately, KRadioButtonGroup hard-codes role="radiogroup", so wrapping checkboxes misreports the control type and applies roving arrow-key navigation where Tab-per-checkbox is correct.
channelEdit/components/AnswersEditor/AnswersEditor.vue:11 already has the pattern:
<component :is="shouldHaveOneCorrectAnswer ? 'KRadioButtonGroup' : 'div'">This change isn't called for by #6033 at all, so reverting it is equally valid. If the wrapper stays for single-select, give it aria-labelledby pointing at the "Answers" heading.
| return state.value.choices.filter(c => c.correct).length; | ||
| }); | ||
|
|
||
| const stateForXml = computed(() => ({ |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
blocking: there is no questionType branch here, so the multi-select rule is applied to single choice as well, and buildChoiceInteractionXML writes state.maxChoices verbatim (interactions/choice/parse.js:126). Three concrete regressions:
- Single choice with no correct answer marked yet →
max-choices="0"(unlimited) instead of1. - Switching multiple → single with 3 answers marked correct →
max-choices="3"alongsidecardinality="single";toggleCorrectChoicedoesn't prune on the type switch. - Every single-choice question with a correct answer now also emits
min-choices="1"(previously dropped, since parsedminChoiceswas0), silently making it mandatory to answer.
#6033 is explicit: "For single choice this checkbox is hidden — max-choices is always 1." Gating stateForXml on questionType.value === QuestionType.SINGLE_SELECT, and leaving minChoices at the parsed value in that case, covers all three.
| Vue.component('BaseMenu', BaseMenu); | ||
| Vue.component('Divider', Divider); | ||
| Vue.component('Icon', Icon); | ||
| Vue.component('Teleport', Teleport); |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
suggestion: this global registration is the only one — jest_config/setup.js registers only ActionLink, so in tests <Teleport> renders as an unknown element with its children in place, and Vue.config.silent = true suppresses the warning. That means the whole describe('Answer settings') block in ChoiceInteractionEditor.spec.js passes because the label rendered inline, not at the teleport target; target resolution, the settingsTargetId string surgery, and the ordering of the two teleported blocks are all untested.
It also makes these components silently depend on shared/app.js having run, which is a bad fit for code living in shared/views/. Importing Teleport from 'vue2-teleport' locally in InteractionSection and ChoiceInteractionEditor (and dropping the global registration) fixes both, and avoids squatting on a Vue 3 built-in name. Worth one integration assertion in QTIItemEditor.spec.js that the settings land inside #qti-question-settings-0.
There was a problem hiding this comment.
Yeah, let's just import vue2-teleport directly where we need it.
| }, | ||
| teleportTarget: { | ||
| type: String, | ||
| default: '#qti-question-settings', |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
suggestion: #qti-question-settings exists nowhere in the app — only the -${index} variant does. Same for InteractionSection's 'qti-interaction-settings' fallback (index.vue:75). They also interact badly: if InteractionSection renders without teleportTarget, v-if="teleportTarget" skips QuestionTypeSelector, so the #qti-interaction-settings div is never created — yet line 36 still passes that truthy string down, so AnswerSettings teleports into a querySelector that resolves to null and the settings silently vanish.
Make both ids required: true with no default, and pass one id consistently instead of stripping and re-adding the # across three components. Better still: if the descriptor exposed a settingsComponent alongside editorComponent, InteractionSection could render it into a QuestionTypeSelector slot directly — no ids, no teleport, no test-environment divergence.
| expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('disables selector when only one option available', () => { |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
suggestion: neither assertion touches :disabled — the hidden input exists with two options too, and the "Type" label is static. This test passes with :disabled deleted entirely. Assert on the control: expect(screen.getByRole('combobox')).toBeDisabled().
More importantly, the component's one behaviour — @update:questionType (index.vue:23) — has no test, and neither do the two acceptance criteria with real logic behind them: "changing the type updates questionType and re-renders ChoiceInteractionEditor", and "cardinality in the response declaration XML updates to match the new type". The second is the one that has to survive any refactor of the buildXML plumbing; a useChoiceInteraction spec that flips questionTypeRef and parses responseDeclarations.value[0] covers it cheaply.
| :questionType="questionType" | ||
| :questionTypeOptions="typeOptions" | ||
| :settingsTargetId="settingsTargetId" | ||
| @update:questionType=" |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
nitpick: a two-statement handler that writes to a setup-owned ref from a template expression. It works in Vue 2.7 (proxyWithRefUnwrap forwards the set), but it hides the mutation from the script block — and the watch(questionType, ..., { immediate: true }) at line ~55 already emits update:questionType, so each selection emits twice. A named onQuestionTypeChange(newType) in setup that only assigns the ref, letting the watcher emit, is clearer and emits once.
Related: currentQuestionType is stored in QTIItemEditor but never fed back as a prop, so this ref is the real source of truth. Fine functionally — worth a comment so the duplicated state isn't read as a wiring bug later.
There was a problem hiding this comment.
Yeah, it'd be best to have named functions coming from the setup method.
| </div> | ||
| </div> | ||
|
|
||
| <div :id="`qti-question-settings-${index}`"></div> |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
nitpick: unique across questions in one QTIEditor, but vue2-teleport resolves targets with document.querySelector, which takes the first document-wide match. Two QTIEditor instances on a page would teleport both selectors into the first card. Suffixing with _uid makes it collision-proof for one interpolation.
| <div | ||
| class="question-type-selector" | ||
| :class="{ 'small-screen': windowIsSmall }" | ||
| :style="{ borderBottom: `1px solid ${$themeTokens.fineLine}` }" |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
praise: the conventions that usually slip are all clean here — no @media (useKResponsiveWindow drives .small-screen), no hard-coded colours, and the only inline bindings are color and borderBottom, both direction-agnostic so RTLCSS has nothing to flip.
rtibblesbot
left a comment
There was a problem hiding this comment.
PR #6059 — 12 findings open, 1 new. CI passing; QA did not run.
Prior-finding status
RESOLVED AnswerSettings/
RESOLVED QuestionTypeSelector/index.vue:1
RESOLVED InteractionSection/index.vue:15
RESOLVED package.json:1
RESOLVED InteractionSection/index.vue:2
RESOLVED AnswerSettings/index.vue:29
RESOLVED AnswerSettings/index.vue:51
UNADDRESSED useInteractionDescriptor.js:70
UNADDRESSED ChoiceInteractionEditor.vue:80
UNADDRESSED useChoiceInteraction.js:39
UNADDRESSED app.js:263
UNADDRESSED ChoiceInteractionEditor.vue:547
UNADDRESSED useChoiceInteraction.js:45
UNADDRESSED useChoiceInteraction.js:21
UNADDRESSED QuestionTypeSelector.spec.js:77
UNADDRESSED AnswerSettings/index.vue:4
UNADDRESSED InteractionSection/index.vue:20
UNADDRESSED QTIItemEditor/index.vue:29
UNADDRESSED ChoiceInteractionEditor.vue:168
ACKNOWLEDGED QuestionTypeSelector/index.vue:6
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Compared the current PR state against findings from a prior review:
- Retrieved prior bot reviews via the GitHub API
- Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
- Only raised NEW findings for newly introduced code
- Ran the same phased review passes as a first review (core, frontend/backend lenses, manual QA when required)
- Synthesized one review from the passes and chose the verdict from the findings, CI status, and QA evidence
AlexVelezLl
left a comment
There was a problem hiding this comment.
I think all rtibblesbot concerns are valid, just found some additional ones from a brief skim :)
| <Teleport | ||
| v-if="teleportTarget" | ||
| :to="teleportTarget" | ||
| > |
There was a problem hiding this comment.
I don't think we need this teleport here, right? We only need to teleport from the editor to the interaction section, not from the interaction section to the QTI item editor, or is this something needed? The idea is that we should have one type selector per interaction; this is why it should go next to the interaction and not in the general qti-item-editor component, because in the future we can have one item with multiple interactions.
There was a problem hiding this comment.
We are having this bug, very likely because of this teleport, when we reorder the questions, the settings node keeps staying on the same item:
Grabacion.de.pantalla.2026-08-02.a.la.s.11.09.09.a.m.mov
| :questionType="questionType" | ||
| :questionTypeOptions="typeOptions" | ||
| :settingsTargetId="settingsTargetId" | ||
| @update:questionType=" |
There was a problem hiding this comment.
Yeah, it'd be best to have named functions coming from the setup method.
| const settingsTargetId = computed(() => { | ||
| if (props.teleportTarget && props.teleportTarget.startsWith('#')) { | ||
| return `${props.teleportTarget.substring(1)}-answer-settings`; | ||
| } | ||
| return 'qti-interaction-settings'; | ||
| }); |
There was a problem hiding this comment.
I don't think we need this after teleportTarget is removed, right?
| :interaction="interactions[0]" | ||
| :mode="mode" | ||
| :showAnswers="showAnswers" | ||
| :teleportTarget="`#qti-question-settings-${index}`" |
There was a problem hiding this comment.
We'll need to be careful if we use index as IDs here, because this is reorderable and that could lead to unexpected behavior; this is similar to https://vuejs.org/guide/essentials/list.html#maintaining-state-with-key.
| <template #actions> | ||
| <KButton | ||
| :text="closeBtnLabel$()" | ||
| @click="showTypeInfoModal = false" | ||
| /> | ||
| </template> |
There was a problem hiding this comment.
Idem, lets use the modal props instead.
| ); | ||
|
|
||
| return { descriptor, questionType, parseError }; | ||
| const typeOptions = computed(() => { |
There was a problem hiding this comment.
Yeah, we should not have this coming from useInteractionDescriptor as it does not depend on the descriptor. The TypeSelector component should be able to declare its own list of type options instead of having it come from props.
| Vue.component('BaseMenu', BaseMenu); | ||
| Vue.component('Divider', Divider); | ||
| Vue.component('Icon', Icon); | ||
| Vue.component('Teleport', Teleport); |
There was a problem hiding this comment.
Yeah, let's just import vue2-teleport directly where we need it.
There was a problem hiding this comment.
I'm not sure how the initial state is preserved here 😅, but this should not happen as it may be confusing to users. If we ever change from choice interaction -> text entry interaction, the choice interaction state should be lost, and if we change back from text entry interaction -> choice interaction, then the choice interaction should be empty, not populated with the previous state.
Grabacion.de.pantalla.2026-08-02.a.la.s.11.29.34.a.m.mov
b385729 to
f5a78d7
Compare
|
📢✨ Before we assign a reviewer, we'll turn on |
rtibblesbot
left a comment
There was a problem hiding this comment.
PR #6059 — 11 of 12 prior findings resolved; 1 still open. The QuestionTypeSelector + AnswerSettings + stateForXml split is a real improvement, and the answer-settings half of #6033 is implemented and tested. The type selector still sources its options from the whole descriptor registry, so a choice question offers text-entry types and picking one silently destroys the authored answers.
- blocking —
QuestionTypeSelectorlists all five registered question types; selecting a text-entry type discards every choice and its correct-answer declarations (inline). - suggestion — the choices-list container tag swaps reactively off debounced validation, remounting every
TipTapEditor; the teleport bridge has no test;defaultStatebranch is dead; XML parsed twice per mount;readonly(state)dropped; "Show answer count" self-clears when no answer is marked correct; selector renders in the card body rather than the card header (worth confirming against the design). - nitpick — registry lookup reimplemented, dead
update:questionTypeemits, stale JSDoc onparse.js, phantomtargetIdtest prop, duplicatevue2-teleportversion specifier.
CI passing. Manual QA did not run for this review, so nothing here is based on the rendered UI.
Prior-finding status
UNADDRESSED — QTIEditor/components/QuestionTypeSelector/index.vue:111 (was composables/useInteractionDescriptor.js) — type selector offers every registered question type
RESOLVED — QTIEditor/interactions/choice/ChoiceInteractionEditor.vue — KRadioButtonGroup wrapping non-radio children in multi-select
RESOLVED — QTIEditor/composables/useChoiceInteraction.js:48 — multi-select max/min rule applied to single choice
RESOLVED — shared/app.js — global Teleport registration not present in the Jest setup
RESOLVED — QTIEditor/interactions/choice/ChoiceInteractionEditor.vue — hardcoded #qti-question-settings teleport target
RESOLVED — QTIEditor/composables/useChoiceInteraction.js — builtXml/bodyXml/responseDeclarations duplicated useInteraction
RESOLVED — QTIEditor/composables/useChoiceInteraction.js — showAnswerCount written into ChoiceState
RESOLVED — QTIEditor/components/QuestionTypeSelector/tests/QuestionTypeSelector.spec.js:108 — disabled-state assertion touched nothing
RESOLVED — QTIEditor/interactions/choice/components/AnswerSettings/index.vue:8 — unassociated group heading
RESOLVED — QTIEditor/components/InteractionSection/index.vue — two-statement handler inline in the template
RESOLVED — QTIEditor/components/QTIItemEditor/index.vue — teleport target id not unique across questions
RESOLVED — QTIEditor/interactions/choice/components/AnswerSettings/index.vue:26 — ariaLabel duplicated the checkbox label
RESOLVED — QTIEditor/components/QuestionTypeSelector/index.vue:6 — praise: theme tokens, useKResponsiveWindow, i18n all clean
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Ran an automatic code-only delta review triggered by new commits on a previously reviewed PR:
- Retrieved prior bot reviews via the GitHub API
- Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
- Only raised NEW findings for newly introduced code
- Core review pass only — specialized frontend/backend lenses and manual QA run when a review is explicitly requested
- Synthesized one review from the passes and chose the verdict from the findings, CI status, and QA evidence
| const labelId = generateRandomSlug('type-selector'); | ||
|
|
||
| const questionTypeOptions = computed(() => { | ||
| return descriptors.flatMap(d => d.getTypeOptions?.(qtiEditorStrings) ?? []); |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
blocking: this flat-maps over the whole registry — descriptors is [choiceDescriptor, textEntryDescriptor] (interactions/index.js:15) and TextEntryInteractionDescriptor.getTypeOptions returns Numeric / Text entry / Free response — so a choice question's dropdown offers five types.
Selecting Numeric on a finished multiple-choice question runs InteractionSection.onUpdateQuestionType (components/InteractionSection/index.vue:61-78), which builds freshState from newDescriptor.parse('', []) and carries across only prompt. Every choice, its content, and the correct-answer declarations are dropped and update:interaction is emitted with the replacement XML — no confirmation, no undo. Switching back yields a single empty choice.
#6033 scopes this out explicitly: "The modal lists only the types that the current interaction plugin supports (for choice: Single choice and Multiple choice)", and out of scope: "Any question type other than single choice and multiple choice."
Source the options from the resolved descriptor — which is what putting getTypeOptions on the descriptor was for. InteractionSection already holds it:
// InteractionSection
const questionTypeOptions = computed(() => descriptor.value.getTypeOptions?.(qtiEditorStrings) ?? []);and pass it in as a prop. That drops the cross-descriptor conversion in onUpdateQuestionType entirely (choice↔choice needs none), makes :disabled="questionTypeOptions.length <= 1" meaningful, and leaves TextEntryInteractionDescriptor.getTypeOptions in place for when text-entry switching is actually built. Keep the existing || { label: '', value: '' } guard on selectedOption, since a descriptor without getTypeOptions yields an empty list.
Two symptoms of the same coupling show up in the tests: QuestionTypeSelector.spec.js has to jest.mock('../../../interactions') to control the options at all, and it('disables selector when only one option available') asserts a state that cannot occur in production.
|
|
||
| <div class="choices-list"> | ||
| <component | ||
| :is="shouldRenderRadioGroup ? 'KRadioButtonGroup' : 'div'" |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
suggestion: shouldRenderRadioGroup reads choiceHasError, which reads errors — written by the debounced validator in useInteraction.js (400 ms after the author stops typing). When is changes the tag the vnodes aren't sameVnode, so Vue replaces the element and rebuilds the whole subtree, including every TipTapEditor in the list.
On a new single-choice question (_defaultState() gives one empty choice): t=0 errors empty → KRadioButtonGroup; t=400 ms the empty-choice error lands → tag swaps to div, list remounts; author types and pauses → error clears → swaps back, remounting again and taking focus and cursor position out of the open editor mid-edit.
The some(...) guard is presumably there because a fully-errored list renders KIcon instead of KRadioButton (lines 120-126), leaving the group with no radio children. Keeping the container tag stable avoids the remount either way — either always use KRadioButtonGroup for single-select and let it handle an empty radio set, or always render a plain div with role="radiogroup" and drop the dynamic component.
| :interaction="interaction" | ||
| :mode="mode" | ||
| :showAnswers="showAnswers" | ||
| :teleportTargetId="settingsTargetId" |
There was a problem hiding this comment.
suggestion: the teleport bridge — the whole point of the PR — has no test. AnswerSettings reaching the selector's target is the one behaviour that can silently fail in production (target not in the document when vue2-teleport resolves #${teleportTargetId} on mount → settings simply don't render).
Nothing covers it today: ChoiceInteractionEditor.spec.js creates its own #test-settings-target on document.body, QuestionTypeSelector.spec.js renders the target but no teleport source, and InteractionSection.spec.js renders both halves but asserts nothing about answer settings. This component owns settingsTargetId and renders both halves, so it's the natural home:
it('renders answer settings inside the question type selector', () => {
renderSection({ interaction: interactionBlock(CHOICE_MULTI_SELECT_XML) });
expect(screen.getByRole('checkbox', { name: tr.$tr('shuffleAnswersLabel') })).toBeInTheDocument();
});There was a problem hiding this comment.
Yeah, it'd be great to have a small regression test here.
| props.interaction.bodyXml, | ||
| props.interaction.responseDeclarations, | ||
| ); | ||
| const freshState = newDescriptor.defaultState |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
suggestion: newDescriptor.defaultState never exists — both descriptors keep _defaultState as a module-local in parse.js, and grep -rn defaultState finds only this call site. The ternary always takes the parse('', []) branch. Either drop the dead branch or expose defaultState on the descriptors and add it to REQUIRED_KEYS in defineInteraction.js so it's enforced. Moot if the blocking finding removes the conversion path.
| @update:interaction="interaction => $emit('update:interaction', interaction)" | ||
| /> | ||
| <div v-else> | ||
| <QuestionTypeSelector |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
suggestion: #6033 places the selector in QTIItemEditor's header row — "QTIItemEditor renders a type selector in the card header" — with the portal bridging the plugin's controls up into it. Here it's inverted: QuestionTypeSelector lives inside InteractionSection (i.e. inside question-card-body) and AnswerSettings teleports down into it, so neither control is in the header. The component-ownership argument for this is reasonable and with border-bottom: fineLine it will probably read as a second header band, but the visual result is the part the issue actually constrains — worth confirming against the design before merge. (Manual QA didn't run on this review, so I have no rendered evidence either way.)
| }; | ||
| }); | ||
|
|
||
| const defaultProps = { |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
suggestion: defaultProps doesn't match the component's props. QuestionTypeSelector declares exactly two, questionType and settingsTargetId (both required); this passes questionTypeOptions and mode, neither of which exists, and omits settingsTargetId — so every render logs a missing-required-prop warning and the target div renders without an id. questionTypeOptions here is inert; the real options come from the jest.mock of ../../../interactions. If the blocking finding is taken, questionTypeOptions becomes a real prop and the mock goes away.
Also, it('disables selector when only one option available') asserts is-disabled on document.querySelector('.ui-select') — a KDS-internal class name that will break silently on a KDS upgrade. toBeDisabled() on the combobox role would survive it.
| }, | ||
|
|
||
| emits: ['update:interaction'], | ||
| emits: ['update:interaction', 'update:questionType'], |
There was a problem hiding this comment.
nitpick: neither this component nor ChoiceInteractionEditor (ChoiceInteractionEditor.vue:565) emits update:questionType — the selector moved out to InteractionSection. Both declarations document an event that doesn't exist.
| const responseIdentifier = root.getAttribute('response-identifier') || RESPONSE_IDENTIFIER; | ||
| const maxChoices = parseInt(root.getAttribute('max-choices') ?? '0', 10); | ||
| const minChoices = parseInt(root.getAttribute('min-choices') ?? '0', 10); | ||
| const maxChoices = root.hasAttribute('max-choices') |
There was a problem hiding this comment.
nitpick: the JSDoc above (lines 20-21) still says max-choices defaults to 0 = unlimited and min-choices defaults to 0. Both are now undefined when the attribute is absent — which is the point of this change, and why the two parse.spec.js assertions moved to toBeUndefined(). Worth saying so in the comment, since "absent vs. explicit 0" is now load-bearing for showAnswerCount.
| }); | ||
|
|
||
| const renderSection = (props = {}) => | ||
| render(InteractionSection, { |
There was a problem hiding this comment.
nitpick: InteractionSection generates settingsTargetId internally and declares no targetId prop — this looks like a leftover from an earlier iteration, and lands as a stray DOM attribute.
| "vue-custom-element": "https://github.com/learningequality/vue-custom-element.git#master", | ||
| "vue-intl": "^3.0.0", | ||
| "vue-router": "3.6.5", | ||
| "vue2-teleport": "^1.2.2", |
There was a problem hiding this comment.
nitpick: kolibri-design-system@5.8.0 already depends on vue2-teleport@1.1.4 (pnpm-lock.yaml:13989); ^1.2.2 resolves a second copy into the store. Tiny package so the cost is negligible, but ^1.1.4 would dedupe.
rtibblesbot
left a comment
There was a problem hiding this comment.
PR #6059 — f5a78d71 unchanged; CI green, no manual QA. 12 of 27 prior findings resolved, 2 acknowledged, 13 open; 2 new blocking inline.
Prior-finding status
RESOLVED — ChoiceInteractionEditor.vue — KCheckbox
RESOLVED — useChoiceInteraction.js:48 — questionType
RESOLVED — shared/app.js — registration
RESOLVED — ChoiceInteractionEditor.vue — target
RESOLVED — useChoiceInteraction.js — duplication
RESOLVED — useChoiceInteraction.js — showAnswerCount
RESOLVED — QuestionTypeSelector.spec.js:108 — disabled
RESOLVED — AnswerSettings/index.vue:8 — heading
RESOLVED — InteractionSection/index.vue — handler
RESOLVED — QTIItemEditor/index.vue — uniqueness
RESOLVED — QuestionTypeSelector/index.vue:6 — praise
RESOLVED — AnswerSettings/index.vue:26 — ariaLabel
ACKNOWLEDGED — useInteractionDescriptor.js — registry
ACKNOWLEDGED — QuestionTypeSelector/index.vue:111 — registry
UNADDRESSED — ChoiceInteractionEditor.vue:82 — wrapper
UNADDRESSED — InteractionSection/index.vue:11 — placement
UNADDRESSED — InteractionSection/index.vue:25 — untested
UNADDRESSED — InteractionSection/index.vue:68 — defaultState
UNADDRESSED — InteractionSection/index.vue:62 — lookup
UNADDRESSED — useInteraction.js:36 — reparse
UNADDRESSED — useChoiceInteraction.js:127 — readonly
UNADDRESSED — useChoiceInteraction.js:24 — max-choices
UNADDRESSED — QuestionTypeSelector.spec.js:22 — defaultProps
UNADDRESSED — TextEntryEditor.vue:410 — emits
UNADDRESSED — parse.js:87 — JSDoc
UNADDRESSED — InteractionSection.spec.js:23 — targetId
UNADDRESSED — package.json:102 — vue2-teleport
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Compared the current PR state against findings from a prior review:
- Retrieved prior bot reviews via the GitHub API
- Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
- Only raised NEW findings for newly introduced code
- Ran the same phased review passes as a first review (core, frontend/backend lenses, manual QA when required)
- Synthesized one review from the passes and chose the verdict from the findings, CI status, and QA evidence
|
|
||
| <div class="choice-editor"> | ||
| <Teleport | ||
| v-if="teleportTargetId" |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
blocking: the guard is on the slot, not the Teleport, so view mode mounts one with no #answer-settings_* target while vue2-teleport@1.2.2 observes document.body subtree-wide. Move it onto <Teleport>.
| const answersHeaderId = generateRandomSlug('answers-header'); | ||
| const shouldRenderRadioGroup = computed(() => { | ||
| return ( | ||
| isSingleSelect.value && state.value.choices.some(choice => !choiceHasError(choice.id)) |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
blocking: choiceHasError reads errors, debounced 400 ms in useInteraction.js:59, so this flips mid-typing; sameVnode() compares tag, so the swap rebuilds .choices-list, remounting every TipTapEditor. Prefer isSingleSelect.value.
| const parsedShowAnswerCount = computed(() => (state.value.maxChoices ?? 1) !== 0); | ||
| const showAnswerCount = ref(parsedShowAnswerCount.value); | ||
|
|
||
| watch(parsedShowAnswerCount, val => { |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
suggestion: dead watcher; parse() sets maxChoices once.
| import { qtiEditorStrings as tr } from '../../../qtiEditorStrings'; | ||
|
|
||
| let mockDescriptors = []; | ||
| jest.mock('../../../interactions', () => ({ |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
suggestion: mocking interactions/index.js leaves getTypeOptions() untested.
| const doc = parser.parseFromString(bodyXml.value, 'text/xml'); | ||
| const interaction = doc.querySelector('qti-choice-interaction'); | ||
|
|
||
| expect(interaction?.getAttribute('max-choices')).toBe('2'); |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
suggestion: unasserted min-choices criterion.
| :interaction="interaction" | ||
| :mode="mode" | ||
| :showAnswers="showAnswers" | ||
| :teleportTargetId="settingsTargetId" |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
suggestion: TextEntryEditor lacks teleportTargetId.
f5a78d7 to
276f313
Compare
rtibblesbot
left a comment
There was a problem hiding this comment.
PR #6059 — 19 of 33 prior findings resolved, 2 acknowledged; 12 still open (see the collapsed block and inline comments).
New this round: simplifying shouldRenderRadioGroup to isSingleSelect dropped the clause that guaranteed the KRadioButtonGroup has at least one KRadioButton child — that throws in Firefox (inline, blocking). The rest are carried-over suggestions and nitpicks.
CI passing. Manual QA did not run this round, so nothing here is based on the rendered UI.
Prior-finding status
RESOLVED — useInteractionDescriptor.js — descriptors filtered to the active interaction
RESOLVED — ChoiceInteractionEditor.vue — radio-group wrapper only for single select
RESOLVED — useChoiceInteraction.js:43 — questionType branch for min/max choices
RESOLVED — shared/app.js — global registration removed
RESOLVED — ChoiceInteractionEditor.vue — teleport target id is generated, not hardcoded
RESOLVED — useChoiceInteraction.js — no longer re-implements useInteraction internals
RESOLVED — useChoiceInteraction.js — showAnswerCount derived from parsed maxChoices
RESOLVED — QuestionTypeSelector.spec.js:59 — disabled state now asserted
RESOLVED — AnswerSettings/index.vue:8 — group heading association
RESOLVED — InteractionSection/index.vue — handler moved out of the template expression
RESOLVED — QTIItemEditor/index.vue — target id uniqueness across questions
RESOLVED — QuestionTypeSelector/index.vue:6 — conventions pass (praise)
RESOLVED — AnswerSettings/index.vue:26 — duplicated ariaLabel
RESOLVED — QuestionTypeSelector/index.vue:111 — options no longer flat-map the whole registry
RESOLVED — ChoiceInteractionEditor.vue:81 — shouldRenderRadioGroup no longer reads debounced errors
RESOLVED — ChoiceInteractionEditor.vue — Teleport itself now guarded on mode === 'edit'
RESOLVED — ChoiceInteractionEditor.vue — debounced-error flip in the radio-group guard
RESOLVED — useChoiceInteraction.js — dead maxChoices watcher removed
RESOLVED — useChoiceInteraction.spec.js:212 — min-choices now asserted
ACKNOWLEDGED — InteractionSection/index.vue:11 — selector placement vs #6033 (author's design call)
ACKNOWLEDGED — InteractionSection/index.vue:25 — TextEntryEditor gets inheritAttrs: false instead of the prop
UNADDRESSED — InteractionSection/index.vue:68 — newDescriptor.defaultState never exists
UNADDRESSED — InteractionSection/index.vue:62 — duplicates getDescriptorForQuestionType
UNADDRESSED — InteractionSection/index.vue:25 — teleport bridge has no test
UNADDRESSED — InteractionSection.spec.js:23 — undeclared targetId prop
UNADDRESSED — useInteraction.js:36 — initialState computed unconditionally
UNADDRESSED — useChoiceInteraction.js:122 — spreading ...base exposes the writable state ref
UNADDRESSED — useChoiceInteraction.js — zero correct choices emits max-choices="0"
UNADDRESSED — QuestionTypeSelector.spec.js:8 — defaultProps do not match declared props
UNADDRESSED — QuestionTypeSelector.spec.js — getTypeOptions still untested
UNADDRESSED — TextEntryEditor.vue:411 — update:questionType declared, never emitted
UNADDRESSED — parse.js:87 — JSDoc still documents the old max/min-choices defaults
UNADDRESSED — package.json:102 — vue2-teleport duplicated against KDS
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Ran an automatic code-only delta review triggered by new commits on a previously reviewed PR:
- Retrieved prior bot reviews via the GitHub API
- Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
- Only raised NEW findings for newly introduced code
- Core review pass only — specialized frontend/backend lenses and manual QA run when a review is explicitly requested
- Synthesized one review from the passes and chose the verdict from the findings, CI status, and QA evidence
| } | ||
|
|
||
| const answersHeaderId = generateRandomSlug('answers-header'); | ||
| const shouldRenderRadioGroup = computed(() => isSingleSelect.value); |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
blocking: dropping the .some(choice => !choiceHasError(choice.id)) clause lets KRadioButtonGroup mount with zero KRadioButton children, because the template swaps each radio for a KIcon when the choice has an error (line 118).
KDS KRadioButtonGroup.mounted does, on Firefox only:
this.queryAndAddRadioBtns(this.$children);
const firstRadioButton = this.radioButtons[this.focusedRadioIdx];
firstRadioButton.setTabIndex(0); // TypeError when radioButtons is []Reachable path: a multi-select question whose choices are all empty or all duplicates (so every choice carries an error once the 400 ms debounced validation lands), then switch to Single choice. <component :is> swaps div → KRadioButtonGroup, it mounts with no radio children, and setTabIndex throws — console error plus no arrow-key navigation in the group.
Restoring the .some(...) clause fixes it. If the goal was to stop the group remounting as errors come and go, the alternative is to keep the radio rendered alongside the error icon rather than replacing it.
| props.interaction.bodyXml, | ||
| props.interaction.responseDeclarations, | ||
| ); | ||
| const freshState = newDescriptor.defaultState |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
suggestion: neither descriptor defines defaultState — ChoiceInteractionDescriptor keeps _defaultState as a module-local export in parse.js, so this branch is always false and only newDescriptor.parse('', []) ever runs. Either expose defaultState on the descriptor interface or drop the ternary.
Line 62 is also exactly getDescriptorForQuestionType from interactions/index.js — reuse it rather than re-deriving the lookup here.
| } | ||
|
|
||
| const effectiveMaxChoices = computed(() => { | ||
| if (!showAnswerCount.value) return 0; |
There was a problem hiding this comment.
suggestion: with showAnswerCount on and no choice marked correct, effectiveMaxChoices is 0, which serializes as max-choices="0" — QTI's unlimited, the opposite of what the setting means. A Math.max(1, ...) floor, or treating zero correct as "no constraint yet", would avoid emitting a value that reads as unlimited.
Separately, return { ...base, ... } (line 122) hands back the raw writable state ref; it was readonly(state) before, so consumers can now mutate state outside the composable's mutators.
| */ | ||
| export function useInteraction(descriptor, interactionBlock, questionType) { | ||
| export function useInteraction(descriptor, interactionBlock, questionType, options = {}) { | ||
| const initialState = descriptor.parse( |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
suggestion: when options.state is supplied this parse is discarded — useChoiceInteraction has already parsed the same block. Two DOMParser passes over the same body on every editor mount; guarding the parse on options.state being absent drops one.
| }); | ||
|
|
||
| it('disables selector when only one option available', () => { | ||
| jest.spyOn(descriptors, 'flatMap').mockReturnValueOnce([ |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
suggestion: jest.spyOn(descriptors, 'flatMap') couples the test to the exact expression inside the computed, and .ui-select / .is-disabled are KDS-internal class names. getTypeOptions — the thing that actually builds the options — stays untested either way. Sourcing the options through the component's own interface would let this set up the single-option case honestly and assert toBeDisabled().
Also: defaultProps (line 11) passes mode, which this component does not declare, so it falls through as a DOM attribute.
| const renderSection = (props = {}) => | ||
| render(InteractionSection, { | ||
| props: { mode: 'edit', ...props }, | ||
| props: { mode: 'edit', targetId: 'test-target', ...props }, |
There was a problem hiding this comment.
suggestion: the teleport bridge — the point of the PR — still has no test: nothing asserts that AnswerSettings renders into the selector's target. A test that renders InteractionSection in edit mode and finds the shuffle checkbox inside the settings container would cover it.
targetId here is also not a declared prop (the id is generated internally), so it only lands as a DOM attribute.
| "vue-custom-element": "https://github.com/learningequality/vue-custom-element.git#master", | ||
| "vue-intl": "^3.0.0", | ||
| "vue-router": "3.6.5", | ||
| "vue2-teleport": "^1.2.2", |
There was a problem hiding this comment.
suggestion: kolibri-design-system@5.8.0 already depends on vue2-teleport@1.1.4 (pnpm-lock.yaml:13989, used by KOverlay); ^1.2.2 resolves to a second copy and both ship in the bundle. Pinning 1.1.4 dedupes unless 1.2.2 is needed for something specific — worth a note in the PR body if it is.
| }, | ||
|
|
||
| emits: ['update:interaction'], | ||
| emits: ['update:interaction', 'update:questionType'], |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
nitpick: update:questionType is declared but never emitted here (same on TextEntryEditor.vue:411) — InteractionSection takes the type from QuestionTypeSelector, not from the editors.
| const responseIdentifier = root.getAttribute('response-identifier') || RESPONSE_IDENTIFIER; | ||
| const maxChoices = parseInt(root.getAttribute('max-choices') ?? '0', 10); | ||
| const minChoices = parseInt(root.getAttribute('min-choices') ?? '0', 10); | ||
| const maxChoices = root.hasAttribute('max-choices') |
There was a problem hiding this comment.
nitpick: the ChoiceState JSDoc above still says maxChoices comes from the attribute with 0 = unlimited and lists answers rather than choices; both are now stale — absent attributes yield undefined, and _defaultState seeds maxChoices: 1.
| return questionType.value === QuestionType.SINGLE_SELECT ? 0 : effectiveMaxChoices.value; | ||
| }); | ||
|
|
||
| const stateForXml = computed(() => ({ |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
praise: deriving maxChoices/minChoices in a computed layered over state, instead of writing them back on every correct-answer toggle, keeps the authored state the single source of truth.
276f313 to
a5051cf
Compare
rtibblesbot
left a comment
There was a problem hiding this comment.
PR #6059 — 22/36 prior findings resolved, 12 open below. CI green; manual QA did not run.
Prior-finding status
RESOLVED — useInteractionDescriptor.js — registry scope
RESOLVED — ChoiceInteractionEditor.vue — empty KRadioButtonGroup
RESOLVED — useChoiceInteraction.js:43 — questionType branch
RESOLVED — app.js — global Teleport
RESOLVED — ChoiceInteractionEditor.vue — #qti-question-settings
RESOLVED — useChoiceInteraction.js — builtXml
RESOLVED — useChoiceInteraction.js — showAnswerCount in state
RESOLVED — QuestionTypeSelector.spec.js:59 — :disabled
RESOLVED — AnswerSettings/index.vue:8 — heading aria-labelledby
RESOLVED — InteractionSection/index.vue — inline handler
RESOLVED — QTIItemEditor/index.vue — index-based ids
RESOLVED — AnswerSettings/index.vue:26 — ariaLabel
RESOLVED — QuestionTypeSelector/index.vue:111 — flatMap scope
RESOLVED — ChoiceInteractionEditor.vue:81 — debounced errors
RESOLVED — InteractionSection/index.vue:11 — selector placement
RESOLVED — ChoiceInteractionEditor.vue — Teleport guard
RESOLVED — ChoiceInteractionEditor.vue — choiceHasError
RESOLVED — useChoiceInteraction.js — dead maxChoices watcher
RESOLVED — useChoiceInteraction.spec.js:212 — min-choices
RESOLVED — InteractionSection/index.vue:25 — teleportTargetId
RESOLVED — ChoiceInteractionEditor.vue:487 — KRadioButtonGroup
RESOLVED — QuestionTypeSelector.spec.js — getTypeOptions()
ACKNOWLEDGED — QuestionTypeSelector/index.vue:6 — praise
ACKNOWLEDGED — useChoiceInteraction.js:43 — praise
UNADDRESSED — InteractionSection/index.vue:61 — onUpdateQuestionType untested
UNADDRESSED — InteractionSection/index.vue:68 — defaultState
UNADDRESSED — InteractionSection/index.vue:62 — getDescriptorForQuestionType
UNADDRESSED — useInteraction.js:31 — double parse
UNADDRESSED — useChoiceInteraction.js:122 — readonly(state)
UNADDRESSED — useChoiceInteraction.js:24 — max-choices="0"
UNADDRESSED — QuestionTypeSelector.spec.js:11 — mode prop
UNADDRESSED — QuestionTypeSelector.spec.js:60 — jest.spyOn flatMap
UNADDRESSED — InteractionSection.spec.js:24 — targetId prop
UNADDRESSED — parse.js:20 — ChoiceState JSDoc
UNADDRESSED — ChoiceInteractionEditor.vue:561 — update:questionType
UNADDRESSED — package.json:102 — vue2-teleport@1.1.4
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Ran an automatic code-only delta review triggered by new commits on a previously reviewed PR:
- Retrieved prior bot reviews via the GitHub API
- Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
- Only raised NEW findings for newly introduced code
- Core review pass only — specialized frontend/backend lenses and manual QA run when a review is explicitly requested
- Synthesized one review from the passes and chose the verdict from the findings, CI status, and QA evidence
| ); | ||
|
|
||
| return { descriptor, questionType, parseError }; | ||
| const onUpdateQuestionType = newType => { |
There was a problem hiding this comment.
suggestion: untested, and the only path that can lose authored content — freshState keeps prompt only, dropping every choice.
| props.interaction.bodyXml, | ||
| props.interaction.responseDeclarations, | ||
| ); | ||
| const freshState = newDescriptor.defaultState |
There was a problem hiding this comment.
nitpick: defaultState is undefined; line 62 duplicates getDescriptorForQuestionType.
| // --------------------------------------------------------------------------- | ||
| // Structural mutations | ||
| // --------------------------------------------------------------------------- | ||
| const showAnswerCount = ref((initialState.maxChoices ?? 1) !== 0); |
There was a problem hiding this comment.
suggestion: 0 conflates unlimited with none correct yet — a new multi-select reopens unchecked, and mount rewrites imported max-choices.
| */ | ||
| export function useInteraction(descriptor, interactionBlock, questionType) { | ||
| export function useInteraction(descriptor, interactionBlock, questionType, options = {}) { | ||
| const initialState = descriptor.parse( |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
suggestion: discarded when options.state is set — useChoiceInteraction.js:18 already parsed.
| }); | ||
|
|
||
| it('disables selector when only one option available', () => { | ||
| jest.spyOn(descriptors, 'flatMap').mockReturnValueOnce([ |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
suggestion: jest.spyOn on flatMap pins the computed’s wording, and is never restored.
There was a problem hiding this comment.
@AlexVelezLl's call to delete this test outright is the better resolution — if a single-option state is unreachable, the honest fix is removing the case, not making the spyOn set it up more cleanly. Withdrawing my suggestion in favour of that.
| const responseIdentifier = root.getAttribute('response-identifier') || RESPONSE_IDENTIFIER; | ||
| const maxChoices = parseInt(root.getAttribute('max-choices') ?? '0', 10); | ||
| const minChoices = parseInt(root.getAttribute('min-choices') ?? '0', 10); | ||
| const maxChoices = root.hasAttribute('max-choices') |
There was a problem hiding this comment.
✅ Resolved — addressed in the current code.
praise: hasAttribute is why showAnswerCount survives a reload.
AlexVelezLl
left a comment
There was a problem hiding this comment.
Thanks @Abhishek-Punhani! Still a couple of things to polish, the main concern is the double parsing that can be resolved if we just abstract the showAnswerCount setting to our parse method.
| :interaction="interaction" | ||
| :mode="mode" | ||
| :showAnswers="showAnswers" | ||
| :teleportTargetId="settingsTargetId" |
There was a problem hiding this comment.
Yeah, it'd be great to have a small regression test here.
| const onUpdateQuestionType = newType => { | ||
| const newDescriptor = descriptors.find(d => d.questionTypes.includes(newType)); | ||
| if (newDescriptor && newDescriptor !== descriptor.value) { | ||
| const oldState = descriptor.value.parse( | ||
| props.interaction.bodyXml, | ||
| props.interaction.responseDeclarations, | ||
| ); | ||
| const freshState = newDescriptor.defaultState | ||
| ? newDescriptor.defaultState() | ||
| : newDescriptor.parse('', []); |
There was a problem hiding this comment.
The problem here is that defaultState does not exist for any descriptor (and should not exist); we can just do:
const freshState = newDescriptor.parse('', []);
| const defaultProps = { | ||
| questionType: QuestionType.SINGLE_SELECT, | ||
| settingsTargetId: 'test-settings-target', | ||
| mode: 'edit', |
There was a problem hiding this comment.
This component does not accept mode as a prop.
| it('disables selector when only one option available', () => { | ||
| jest.spyOn(descriptors, 'flatMap').mockReturnValueOnce([ | ||
| { | ||
| value: QuestionType.SINGLE_SELECT, | ||
| label: tr.$tr('singleSelectLabel'), | ||
| description: tr.$tr('singleChoiceDescription'), | ||
| }, | ||
| ]); | ||
| renderHeader(); | ||
|
|
||
| // The KSelect wrapper gets the 'is-disabled' class when disabled | ||
| expect(document.querySelector('.ui-select')).toHaveClass('is-disabled'); | ||
| }); |
There was a problem hiding this comment.
I think we can just get rid of this test, given that we won't have just one option (never :)). It's probably just an old test case from the old component that used to receive the options via props.
| const selectedOption = computed( | ||
| () => | ||
| questionTypeOptions.value.find(o => o.value === props.questionType) || | ||
| questionTypeOptions.value[0] || { label: '', value: '' }, |
There was a problem hiding this comment.
I don't think we will need this { label: '', value: '' } fallback given that questionTypeOptions will always have options!
| const initialState = choiceInteractionDescriptor.parse( | ||
| interactionBlock.bodyXml, | ||
| interactionBlock.responseDeclarations, | ||
| ); | ||
| const state = ref(initialState); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Structural mutations | ||
| // --------------------------------------------------------------------------- | ||
| const showAnswerCount = ref((initialState.maxChoices ?? 1) !== 0); | ||
|
|
||
| function setShowAnswerCount(val) { | ||
| showAnswerCount.value = val; | ||
| } | ||
|
|
||
| const effectiveMaxChoices = computed(() => { | ||
| if (!showAnswerCount.value) return 0; | ||
| return state.value.choices.filter(c => c.correct).length; | ||
| }); | ||
|
|
||
| const maxChoicesForXml = computed(() => { | ||
| return questionType.value === QuestionType.SINGLE_SELECT ? 1 : effectiveMaxChoices.value; | ||
| }); | ||
|
|
||
| const minChoicesForXml = computed(() => { | ||
| return questionType.value === QuestionType.SINGLE_SELECT ? 0 : effectiveMaxChoices.value; | ||
| }); | ||
|
|
||
| const stateForXml = computed(() => ({ | ||
| ...state.value, | ||
| maxChoices: maxChoicesForXml.value, | ||
| minChoices: minChoicesForXml.value, | ||
| })); | ||
|
|
||
| const base = useInteraction(choiceInteractionDescriptor, interactionBlock, questionType, { | ||
| state, | ||
| stateForXml, | ||
| }); |
There was a problem hiding this comment.
I'm not getting all of these changes, and why we need a previous parse of the choice interaction instead of relying on useInteraction, and why useInteraction needs a state and stateForXml.
I suspect all of this comes from the need for the showAnswerCount setting, but the thing is that this is also a state in our internal representation, and it should be parsed (computed) directly in the parse method. This way, showAnswerCount is just another field of the state object returned by parseChoiceInteraction, and updating it should be similar to the other update state methods like addChoice, etc. Then, in the buildXml method, we can take this as part of the state and update it like we update any other state field; then the buildXml will always see the same state object, instead of having the useInteraction receive a state or stateForXml as options.
| import Teleport from 'vue2-teleport'; | ||
| import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow'; | ||
| import { themePalette, themeTokens } from 'kolibri-design-system/lib/styles/theme'; | ||
| import KRadioButtonGroup from 'kolibri-design-system/lib/KRadioButtonGroup'; |
There was a problem hiding this comment.
We don't need to declare this; it's already declared globally on the KDS installation.
| }, | ||
|
|
||
| emits: ['update:interaction'], | ||
| emits: ['update:interaction', 'update:questionType'], |
| @click.stop | ||
| > | ||
| <!-- Error icon replaces selection control when choice has an error --> | ||
| <KIcon |
| name: 'TextEntryEditor', | ||
|
|
||
| components: { TipTapEditor, ValidationMessage, AddListItemButton }, | ||
| inheritAttrs: false, |
There was a problem hiding this comment.
InteractionSection passes teleportTargetId to all interaction components. TextEntryEditor doesn't use Teleport, so I added inheritAttrs: false
…iceInteractionEditor Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
…eaders Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
a5051cf to
adeb9ce
Compare
rtibblesbot
left a comment
There was a problem hiding this comment.
PR #6059 — 27 of 38 findings resolved, 7 open. CI passing; no manual QA.
Also:
parse.js:20—maxChoices/minChoiceswrite-only now; JSDoc stale.useChoiceInteraction.js:95—state: readonly(state)dropped;...baseexposes a writable ref.ChoiceInteractionDescriptor.js:56—max-choices === '1'now meanscorrectCount; a one-answerMULTI_SELECTreads back asSINGLE_SELECT.- Nitpicks —
InteractionSection/index.vue:62,74,TextEntryEditor.vue:411,QuestionTypeSelector/index.vue:25,33,package.json:102— re-derived helper, double emit, unemittedemits, dead:disabled, straylanguageicon, duplicate dep.
Prior-finding status
RESOLVED — useInteractionDescriptor.js — registry scope (superseded)
RESOLVED — ChoiceInteractionEditor.vue — empty KRadioButtonGroup
RESOLVED — useChoiceInteraction.js:43 — questionType branch
RESOLVED — app.js — global Teleport
RESOLVED — ChoiceInteractionEditor.vue — #qti-question-settings
RESOLVED — useChoiceInteraction.js — builtXml
RESOLVED — useChoiceInteraction.js — showAnswerCount in state
RESOLVED — QuestionTypeSelector.spec.js:59 — :disabled
RESOLVED — AnswerSettings/index.vue:8 — heading aria-labelledby
RESOLVED — InteractionSection/index.vue — inline handler
RESOLVED — QTIItemEditor/index.vue — index-based ids
RESOLVED — AnswerSettings/index.vue:26 — ariaLabel
RESOLVED — QuestionTypeSelector/index.vue:111 — flatMap scope
RESOLVED — ChoiceInteractionEditor.vue:81 — debounced errors
RESOLVED — ChoiceInteractionEditor.vue — Teleport guard
RESOLVED — ChoiceInteractionEditor.vue — choiceHasError
RESOLVED — useChoiceInteraction.js — dead maxChoices watcher
RESOLVED — useChoiceInteraction.spec.js:212 — min-choices
RESOLVED — InteractionSection/index.vue:25 — teleportTargetId
RESOLVED — ChoiceInteractionEditor.vue:487 — KRadioButtonGroup
RESOLVED — QuestionTypeSelector.spec.js — getTypeOptions()
RESOLVED — QuestionTypeSelector.spec.js:11 — mode prop
RESOLVED — QuestionTypeSelector.spec.js:60 — jest.spyOn flatMap (withdrawn)
RESOLVED — InteractionSection/index.vue:68 — defaultState
RESOLVED — useInteraction.js:31 — double parse
RESOLVED — parse.js:20 — ChoiceState JSDoc rename
RESOLVED — ChoiceInteractionEditor.vue:561 — update:questionType
ACKNOWLEDGED — QuestionTypeSelector/index.vue:6 — praise
ACKNOWLEDGED — useChoiceInteraction.js:43 — praise
ACKNOWLEDGED — parse.js:89 — hasAttribute praise
ACKNOWLEDGED — InteractionSection/index.vue:11 — selector placement
ACKNOWLEDGED — InteractionSection/index.vue:25 — inheritAttrs on TextEntryEditor
UNADDRESSED — InteractionSection/index.vue:61 — onUpdateQuestionType untested
UNADDRESSED — InteractionSection/index.vue:62 — getDescriptorForQuestionType
UNADDRESSED — useChoiceInteraction.js:24 — max-choices="0"
UNADDRESSED — useChoiceInteraction.js:95 — readonly(state) dropped
UNADDRESSED — InteractionSection.spec.js:24 — targetId not a declared prop
UNADDRESSED — TextEntryEditor.vue:411 — update:questionType never emitted
UNADDRESSED — package.json:102 — vue2-teleport alongside KDS 1.1.4
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Ran an automatic code-only delta review triggered by new commits on a previously reviewed PR:
- Retrieved prior bot reviews via the GitHub API
- Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
- Only raised NEW findings for newly introduced code
- Core review pass only — specialized frontend/backend lenses and manual QA run when a review is explicitly requested
- Synthesized one review from the passes and chose the verdict from the findings, CI status, and QA evidence
| return { descriptor, questionType, parseError }; | ||
| const onUpdateQuestionType = newType => { | ||
| const newDescriptor = descriptors.find(d => d.questionTypes.includes(newType)); | ||
| if (newDescriptor && newDescriptor !== descriptor.value) { |
There was a problem hiding this comment.
blocking: SINGLE_SELECT / MULTI_SELECT share choiceDescriptor, so state.choices survives. buildChoiceInteractionXML writes every correct id (parse.js:187) under cardinality: 'single'; update:rawData persists it and validateChoiceInteraction raises TOO_MANY_CORRECT_ANSWERS. Fix: watch questionType in useChoiceInteraction and collapse choices to one correct, as toggleCorrectChoice does.
There was a problem hiding this comment.
But it should be upto user which option to mark as only correct option or we just take option randomly ? @AlexVelezLl
There was a problem hiding this comment.
Not random — deterministic, and the UI already makes the pick. ChoiceInteractionEditor.vue:339 computes correctChoiceId as state.choices.find(a => a.correct)?.id, and the radios bind to it, so right after a multi→single switch the author already sees exactly one option selected: the first correct in document order. The extra correct flags survive only in state and in the XML buildChoiceInteractionXML writes. Collapsing to that same first-correct choice imposes nothing new on the author — it just makes the persisted data match what's already on screen.
The banner does fire (tooManyCorrectError), so it isn't silent, but it points at choices the author can't see as selected, and it goes out to update:rawData before they touch anything.
Clearing every correct flag instead is the other defensible option — explicit re-pick — but it swaps one error for NO_CORRECT_ANSWER (validation.js:42) and throws away the answer key. My preference is keep-first; either way it's @AlexVelezLl's call on the UX.
| }); | ||
|
|
||
| describe('type switching', () => { | ||
| it('preserves the prompt but resets choices when switching from choice to text-entry', async () => { |
There was a problem hiding this comment.
blocking: onUpdateQuestionType is never called; assertions read the initial emit. index.vue:63-72 stays uncovered — drive KSelect to text entry, then assert bodyXml drops Mercury.
| })); | ||
|
|
||
| return { responseIdentifier, prompt, choices, maxChoices, minChoices, shuffle, orientation }; | ||
| const showAnswerCount = (maxChoices ?? 1) !== 0; |
There was a problem hiding this comment.
suggestion: disagrees with line 147 at correctCount === 0: a fresh multi-select serializes max-choices="0", parses back showAnswerCount: false, reopens unchecked. Emit max-choices only when correctCount > 0, as line 157 does.
…tegrate question type selection into QTI editor sections Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
adeb9ce to
0bdd27b
Compare
rtibblesbot
left a comment
There was a problem hiding this comment.
PR #6059 — 25 of 38 prior findings resolved, 2 acknowledged; 11 still open (1 blocking). 5 new findings this round.
0bdd27bda fixes the type-selector scope problem at the right layer: getTypeOptions now lives on the descriptors, and onUpdateQuestionType converts state rather than dropping the editor on a foreign block. CI passing. Manual QA did not run this round, so nothing here is a claim about the rendered UI.
Still open
- blocking —
InteractionSection/index.vue:63— a multi→single switch leaves extracorrectflags in the persisted XML. Unchanged in0bdd27bda; waiting on @AlexVelezLl's UX call between keep-first-correct and clear-all, so not re-argued here. InteractionSection/index.vue:62—getDescriptorForQuestionTypere-derived inline.InteractionSection/index.vue:74—update:questionTypeemitted twice per switch.useChoiceInteraction.js:93—readonly(state)dropped;...basehands back a writable ref.parse.js:20—maxChoices/minChoicesJSDoc still says0 = unlimited.ChoiceInteractionDescriptor.js:56—max-choices === '1'fallback collides with a one-answerMULTI_SELECT.TextEntryEditor.vue:411—update:questionTypedeclared but never emitted.QuestionTypeSelector/index.vue:25—:disabled="questionTypeOptions.length <= 1"is unreachable;:33—language(globe) icon in the#displayslot.InteractionSection.spec.js:24—targetIdis not a declared prop.package.json:102—vue2-teleport@^1.2.2alongside KDS's1.1.4;pnpm-lock.yaml:7711,7714confirms both versions ship.
New findings inline.
Prior-finding status
RESOLVED — useInteractionDescriptor.js — KSelect offered types from the whole registry
RESOLVED — QuestionTypeSelector/index.vue:111 — selector flat-mapped every registered type
RESOLVED — ChoiceInteractionEditor.vue — radio-group guard assumed a KRadioButton list
RESOLVED — ChoiceInteractionEditor.vue:81 — shouldRenderRadioGroup read debounced errors
RESOLVED — ChoiceInteractionEditor.vue:483 — KRadioButtonGroup could mount with zero valid choices
RESOLVED — ChoiceInteractionEditor.vue — Teleport mounted in view mode with no target
RESOLVED — ChoiceInteractionEditor.vue — #qti-question-settings target id mismatch
RESOLVED — useChoiceInteraction.js — multi-select rule applied to single choice
RESOLVED — useChoiceInteraction.js — builtXml/bodyXml/responseDeclarations duplicated useInteraction
RESOLVED — useChoiceInteraction.js — showAnswerCount not part of ChoiceState
RESOLVED — useChoiceInteraction.js — dead maxChoices watcher
RESOLVED — useChoiceInteraction.js:24 — max-choices="0" emitted with zero correct answers
RESOLVED — useInteraction.js:28 — initialState computed then discarded
RESOLVED — InteractionSection/index.vue — newDescriptor.defaultState never exists
RESOLVED — InteractionSection/index.vue:11 — selector placement vs #6033
RESOLVED — InteractionSection/index.vue:25 — TextEntryEditor lacked teleportTargetId
RESOLVED — InteractionSection/index.vue — two-statement template handler writing a setup ref
RESOLVED — InteractionSection/index.vue:61 — onUpdateQuestionType untested
RESOLVED — QTIItemEditor/index.vue — teleport target id collisions across editors
RESOLVED — QuestionTypeSelector.spec.js:7 — defaultProps did not match declared props
RESOLVED — QuestionTypeSelector.spec.js — :disabled never asserted
RESOLVED — QuestionTypeSelector.spec.js — jest.spyOn on flatMap (withdrawn)
RESOLVED — QuestionTypeSelector.spec.js — getTypeOptions untested behind the module mock
RESOLVED — useChoiceInteraction.spec.js:212 — unasserted min-choices criterion
RESOLVED — shared/app.js — global registration absent from jest setup
ACKNOWLEDGED — InteractionSection/index.vue:11 — selector renders in the card body, not the header
ACKNOWLEDGED — InteractionSection/index.vue:25 — inheritAttrs: false on TextEntryEditor
UNADDRESSED — InteractionSection/index.vue:63 — multi→single leaves extra correct flags in XML
UNADDRESSED — InteractionSection/index.vue:62 — getDescriptorForQuestionType re-derived inline
UNADDRESSED — InteractionSection/index.vue:74 — update:questionType emitted twice per switch
UNADDRESSED — InteractionSection/index.vue:25 — teleport bridge has no test
UNADDRESSED — InteractionSection.spec.js:24 — targetId is not a declared prop
UNADDRESSED — useChoiceInteraction.js:93 — readonly(state) dropped
UNADDRESSED — parse.js:20 — JSDoc still says 0 = unlimited
UNADDRESSED — ChoiceInteractionDescriptor.js:56 — max-choices === '1' collides with one-answer MULTI_SELECT
UNADDRESSED — TextEntryEditor.vue:411 — update:questionType declared but never emitted
UNADDRESSED — QuestionTypeSelector/index.vue:25 — :disabled guard unreachable
UNADDRESSED — QuestionTypeSelector/index.vue:33 — language (globe) icon in the #display slot
UNADDRESSED — package.json:102 — two vue2-teleport versions ship
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Ran an automatic code-only delta review triggered by new commits on a previously reviewed PR:
- Retrieved prior bot reviews via the GitHub API
- Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
- Only raised NEW findings for newly introduced code
- Core review pass only — specialized frontend/backend lenses and manual QA run when a review is explicitly requested
- Synthesized one review from the passes and chose the verdict from the findings, CI status, and QA evidence
| }); | ||
|
|
||
| describe('Answer settings', () => { | ||
| it('renders Answer settings section in edit mode', () => { |
There was a problem hiding this comment.
suggestion: these assertions don't test the teleport. beforeEach creates #test-settings-target and appends it to document.body, but every assertion in this block is a document-wide screen query — screen.getByText(tr.$tr('answerSettingsLabel')) passes whether AnswerSettings was moved into the target or left where it rendered.
Nothing else covers the wiring either: InteractionSection generates settingsTargetId, passes it to QuestionTypeSelector as settingsTargetId (which becomes a DOM id) and to the editor as teleportTargetId. Four names have to agree for the feature to work, and no test renders the pair together and looks for the checkbox inside the target — rename any one and the settings quietly stop appearing in the header with a green suite.
Scoping is one line:
expect(within(teleportContainer).getByText(tr.$tr('answerSettingsLabel'))).toBeInTheDocument();plus an InteractionSection edit-mode test that finds the shuffle checkbox inside document.getElementById(...) of the rendered selector.
| })); | ||
|
|
||
| return { responseIdentifier, prompt, choices, maxChoices, minChoices, shuffle, orientation }; | ||
| const showAnswerCount = (maxChoices ?? 1) !== 0; |
There was a problem hiding this comment.
suggestion: this line is the only thing that makes an unchecked "Show learners how many answers to select" survive a reload, and no test drives it. parse.spec.js:137 asserts the default true; useChoiceInteraction.spec.js:184 reads true out of a fixture hardcoding max-choices="1"/"2". The write half is covered (useChoiceInteraction.spec.js:215 asserts max-choices="0"), the read half isn't. A parse.spec.js case feeding max-choices="0" and asserting showAnswerCount === false closes the round trip.
| await fireEvent.click(textEntryOption); | ||
|
|
||
| const emits = emitted()['update:interaction']; | ||
| const switchXml = emits[1][0].bodyXml; |
There was a problem hiding this comment.
suggestion: emits[1] is the switch emit only because ChoiceInteractionEditor's { immediate: true } watcher contributes exactly one emit before it. Change how many times an editor emits on mount and this silently re-points at a different payload, with not.toContain('Mercury') then passing for the wrong reason. .at(-1) — the idiom already used in ChoiceInteractionEditor.spec.js — reads the post-switch state directly.
| } | ||
|
|
||
| const answersHeaderId = generateRandomSlug('answers-header'); | ||
| const shouldRenderRadioGroup = computed(() => isSingleSelect.value); |
There was a problem hiding this comment.
nitpick: shouldRenderRadioGroup is a second name for isSingleSelect with no added meaning — the template can read isSingleSelect in both places it uses it.
| </div> | ||
| </div> | ||
| <!-- Per-choice validation messages sit INSIDE the bordered card --> | ||
| <ValidationMessage |
There was a problem hiding this comment.
nitpick: <!-- Per-choice validation messages sit INSIDE the bordered card --> was dropped, but both ValidationMessages are still inside .choice-border. (The sibling deletion is correct — the icon no longer replaces the control.) AGENTS.md asks that comments be preserved unless the described code is gone or the comment is wrong.
| this.convertsFrom = []; | ||
| } | ||
|
|
||
| getTypeOptions(tr) { |
There was a problem hiding this comment.
praise: moving getTypeOptions onto the descriptors, and pairing the registry-wide selector with a real conversion in onUpdateQuestionType, fixes the prior blocking finding at the right layer instead of narrowing the dropdown.

Summary
Added
QuestionSettingsHeaderandAnswerSettingscomponents to the QTI editor using portals.This PR adds:
QuestionSettingsHeader/index.vue— UI component for selecting interaction types.AnswerSettings/index.vue— UI component for interaction-specific configurations.References
Closes #6033
Reviewer guidance
AI usage
Used Antigravity for a final review and minor code/style nitpicks. I reviewed all suggested changes, kept only the relevant improvements, and verified that the implementation worked as intended after applying them.